Skip to content

Comments

Contribute to splice as acceptor#4416

Open
jkczyz wants to merge 5 commits intolightningdevkit:mainfrom
jkczyz:2026-02-splice-acceptor
Open

Contribute to splice as acceptor#4416
jkczyz wants to merge 5 commits intolightningdevkit:mainfrom
jkczyz:2026-02-splice-acceptor

Conversation

@jkczyz
Copy link
Contributor

@jkczyz jkczyz commented Feb 12, 2026

When both nodes want to splice simultaneously, the quiescence tie-breaker designates one as the initiator. Previously, the losing node responded with zero contribution, requiring a second full splice session after the first splice locked. This is wasteful, especially for often-offline nodes that may connect and immediately want to splice.

Instead, the losing node contributes to the winner's splice as the acceptor, merging both contributions into a single splice transaction. Since the FundingContribution was originally built with initiator fees (which include common fields and shared input/output weight), the fee is adjusted to the acceptor rate before contributing, with the surplus
returned to the change output.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Feb 12, 2026

👋 Thanks for assigning @wpaulino as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@jkczyz jkczyz self-assigned this Feb 12, 2026
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from fc3e1da to 758ab0a Compare February 18, 2026 01:02
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch 3 times, most recently from b6cec12 to 5a26025 Compare February 23, 2026 17:16
@codecov
Copy link

codecov bot commented Feb 23, 2026

Codecov Report

❌ Patch coverage is 92.67782% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.03%. Comparing base (24062c0) to head (e9c5791).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/funding.rs 92.56% 22 Missing and 5 partials ⚠️
lightning/src/ln/channel.rs 90.90% 4 Missing and 1 partial ⚠️
lightning/src/ln/interactivetxs.rs 95.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4416      +/-   ##
==========================================
+ Coverage   85.94%   86.03%   +0.09%     
==========================================
  Files         159      159              
  Lines      104607   104731     +124     
  Branches   104607   104731     +124     
==========================================
+ Hits        89901    90110     +209     
+ Misses      12194    12120      -74     
+ Partials     2512     2501      -11     
Flag Coverage Δ
tests 86.03% <92.67%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from 5a26025 to 658f332 Compare February 23, 2026 22:06
jkczyz and others added 4 commits February 24, 2026 12:41
Now that the Splice variant (containing non-serializable
FundingContribution) is the only variant produced, and the previous
commit consumes the acceptor's quiescent_action in splice_init(),
there is no longer a need to persist it. This allows removing
LegacySplice, SpliceInstructions, ChangeStrategy, and related code
paths including calculate_change_output, calculate_change_output_value,
and the legacy send_splice_init method.

With ChangeStrategy removed, the only remaining path in
calculate_change_output was FromCoinSelection which always returned
Ok(None), making it dead code. The into_interactive_tx_constructor
method is simplified accordingly, and the signer_provider parameter
is removed from it and from splice_init/splice_ack since it was only
needed for the removed change output calculation.

On deserialization, quiescent_action (TLV 65) is still read for
backwards compatibility but discarded, and the awaiting_quiescence
channel state flag is cleared since it cannot be acted upon without
a quiescent_action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the single public InteractiveTxConstructor::new() with separate
new_for_outbound() and new_for_inbound() constructors. This moves the
initiator's first message preparation out of the core constructor,
making it infallible and removing is_initiator from the args struct.

Callers no longer need to handle constructor errors, which avoids having
to generate SpliceFailed/DiscardFunding events after the QuiescentAction
has already been consumed during splice_init/splice_ack handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When constructing a FundingContribution, it's always assumed the
estimated_fee is for when used as the initiator, who pays for the common
fields and shared inputs / outputs. However, when the contribution is
used as the acceptor, we'd be overpaying fees. Provide a method on
FundingContribution that adjusts the fees and the change output, if
possible.
Add a `change_output: Option<&TxOut>` parameter to
`estimate_transaction_fee` so the initial fee estimate accounts for the
change output's weight. Previously, the change output weight was omitted
from `estimated_fee` in `FundingContribution`, causing the estimate to
be slightly too low when a change output was present.

This also eliminates an unnecessary `Vec<TxOut>` allocation in
`compute_feerate_adjustment`, which previously cloned outputs into a
temporary Vec just to include the change output for the fee estimate.

A mock `TightBudgetWallet` is added to `splicing_tests` to demonstrate
that `validate()` correctly rejects contributions where the input value
is sufficient without the change output weight but insufficient with it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from 658f332 to 4274f75 Compare February 24, 2026 21:16
@jkczyz jkczyz marked this pull request as ready for review February 24, 2026 21:19
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from 4274f75 to a1fe138 Compare February 24, 2026 22:05
When both nodes want to splice simultaneously, the quiescence
tie-breaker designates one as the initiator. Previously, the losing
node responded with zero contribution, requiring a second full splice
session after the first splice locked. This is wasteful, especially for
often-offline nodes that may connect and immediately want to splice.

Instead, the losing node contributes to the winner's splice as the
acceptor, merging both contributions into a single splice transaction.
Since the FundingContribution was originally built with initiator fees
(which include common fields and shared input/output weight), the fee is
adjusted to the acceptor rate before contributing, with the surplus
returned to the change output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from a1fe138 to e9c5791 Compare February 24, 2026 22:20
{1, LegacySplice} => (),
);
#[cfg(not(any(test, fuzzing, feature = "_test_utils")))]
impl_writeable_tlv_based_enum_upgradable!(QuiescentAction,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be removed?

self.send_splice_init_internal(context, ChangeStrategy::LegacyUserProvided(change_script))
}

fn send_splice_init_internal(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop _internal now?

))
})?;
);
let tx_msg_opt = interactive_tx_constructor.take_initiator_first_message();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's return this as part of the constructor now and drop take_initiator_first_message?

/// Returns `Ok((new_estimated_fee, new_change_value))` or `Err`:
/// - `(fee, Some(change))` — inputs with change: both should be updated
/// - `(fee, None)` — inputs without change (or change removed), or splice-out: fee updated
/// only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete comment?

Comment on lines +398 to +400
let msg_events = acceptor.node.get_and_clear_pending_msg_events();
assert_eq!(msg_events.len(), 1, "{msg_events:?}");
if let MessageSendEvent::SendTxComplete { ref msg, .. } = msg_events.remove(0) {
initiator.node.handle_tx_complete(node_id_acceptor, msg);
} else {
panic!();
match &msg_events[0] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make the other node follow this pattern as well?

.map_err(|e| {
log_info!(
logger,
"Cannot accommodate initiator's feerate for channel {}: {}; \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include the feerate we couldn't afford

let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?;
let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64);
let our_funding_contribution = self.queued_funding_contribution().and_then(|c| {
c.net_value_for_acceptor_at_feerate(feerate)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably shouldn't blindly accept the counterparty's feerate, but asking the user to approve it isn't ideal either.

/// - `(fee, Some(change))` — inputs with change: both should be updated
/// - `(fee, None)` — inputs without change (or change removed), or splice-out: fee updated
/// only
fn compute_feerate_adjustment(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs should probably mention this is only intended to be used in the non-initiator contribution case

is_splice,
target_feerate,
);
if budget < fair_fee {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have a mixed splice where we fully consume an input and cover the remaining outstanding amount with the channel balance, then budget should probably also reflect the channel balance available to spend?


let mut pending_splice: Option<PendingFunding> = None;
let mut quiescent_action = None;
let mut _quiescent_action: Option<QuiescentAction> = None;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just remove this entirely and not even mention it as its an odd TLV.

.ok_or("Budget plus change value overflow".to_string())?;

match available.checked_sub(fair_fee) {
Some(new_change_value) if new_change_value >= dust_limit => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In both the no-input and no-change case we fail if we'd pay a fee higher than our initially-estimated fee, but here we'll happily continue even if we'd end up reducing change or even removing it. ISTM we should maybe fail instead, as we'd maybe prefer to do our own splice with our own feerate on top of the counterparty's splice later or even splice on another channel instead instead of paying more in fees. Its complicated though, for someone with only a single channel who wants to pay immediately they'd prefer it to work, but in that setup there's a chance that the counterparty just beats them to the punch anyway, so maybe its "something they have to deal with either way" and its fine?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants